我們來接續昨天製作的管理分類功能吧,昨天我們實作查詢和刪除的功能,今天我們將進一步實作新增分類的功能,讓使用者能夠輕鬆地將新的分類加入到管理系統中。Let's GO!
今天的目標是實作一個新增分類的功能,這包括讓使用者輸入分類名稱、選擇分類圖案,以及選擇對應的大分類。完成後,新的分類將被儲存至資料庫中,並且即時更新至分類列表。UI 設計和昨天一樣都是參考簡單記帳。
新增分類的 ViewModel - AddCategoryViewModel。
這個 ViewModel 將負責負責新增分類資料,我們需要在 ViewModel 中實作以下功能:
fetchCategoryGroups()
,用來從資料庫中載入所有已存在的大分類。addCategory(name:iconName:categoryGroup:)
,負責將使用者輸入的分類名稱、圖案和選擇的大分類存入 Core Data。以下是 AddCategoryViewModel 的完整程式碼:
import SwiftUI
class AddCategoryViewModel: ObservableObject {
@Published var categoryGroups: [CategoryGroup] = []
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
init(dataManager: DataManager = DataManager()) {
self.dataManager = dataManager
fetchCategoryGroups()
}
func fetchCategoryGroups() {
categoryGroups = dataManager.fetchCategoryGroups()
}
func addCategory(name: String, iconName: String, categoryGroup: CategoryGroup) {
if name == "" {
failHandle = (isFail: true, title: "發生錯誤")
} else {
let result = dataManager.addItemCategory(name: name, iconName: iconName, categoryGroup: categoryGroup)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "發生錯誤")
}
}
}
}
接下來,我們實作 AddCategoryView,用來讓使用者新增分類的頁面。我們會依照前面設計的 ViewModel,來實現 UI 的互動和資料處理。這個頁面允許使用者輸入分類名稱、選擇圖案以及選擇對應的大分類。接下來,我們將一步一步實作這個 View。
在 AddCategoryView 中,我們定義了一些 State 變數來管理頁面上的互動資料:
透過 @ObservedObject
來監聽 AddCategoryViewModel,這樣可以讓 UI 動態更新,並透過 @Environment(\.dismiss)
來管理頁面的返回功能。
struct AddCategoryView: View {
@State private var categoryName: String = ""
@State private var selectedCategoryGroup: CategoryGroup
@State private var selectedIconName: String
@ObservedObject var viewModel: AddCategoryViewModel
@Environment(\.dismiss) private var dismiss
let icons = [
"frying.pan", "fork.knife", "cup.and.saucer", "oven", // 其餘省略...
]
//以下略...
在挑選 SF Symbols 圖案時,請務必確認這些圖案支援的 iOS 版本,因為有些圖案是 iOS 18 才新增的,這代表在 iOS 18 以前的系統中無法正常顯示這些圖案。
參考資料:SF Symbols
頁面的上半部分是一個分類名稱的輸入框,左邊顯示使用者選定的圖案。如果使用者還沒有選擇圖案,預設顯示的是 "frying.pan" 圖案。輸入框的設計上使用了 TextField
,並且包在一個有灰色背景的框架中。
HStack {
if !selectedIconName.isEmpty {
Image(systemName: selectedIconName)
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
.foregroundColor(Color(hexString: selectedCategoryGroup.colorHex))
}
TextField("請輸入分類名稱", text: $categoryName)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
}
中間的區域是大分類的選擇區塊。這裡使用 LazyVGrid
來顯示大分類,當使用者點擊某個分類時,該分類的背景色會變成分類的代表色,並且文字顏色會變成白色。這部分的選擇使用 Button
來處理點擊事件,每次點擊會更新 selectedCategoryGroup
。
let width = UIScreen.main.bounds.width / 6
LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 16) {
ForEach(viewModel.categoryGroups, id: \.id) { group in
let isSelected = selectedCategoryGroup == group
let backgroundColor = isSelected ? Color(hexString: group.colorHex) : Color(.clear)
let textColor = isSelected ? Color.white : Color.black
Button(action: {
selectedCategoryGroup = group
}) {
Text(group.name)
.font(.system(size: 16, weight: .bold))
.padding()
.frame(maxWidth: .infinity)
.background(backgroundColor)
.foregroundColor(textColor)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray, lineWidth: 2)
)
}
}
}
頁面的下方區域是圖案選擇區,使用 LazyVGrid
來顯示多個圖案,並允許使用者點擊來選擇圖案。選中的圖案會以大分類的顏色來顯示,而未選中的圖案則保持灰色。
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(icons, id: \.self) { icon in
Button(action: {
selectedIconName = icon
}) {
Image(systemName: icon)
.resizable()
.foregroundColor(selectedIconName == icon ? Color(hexString: selectedCategoryGroup.colorHex) : Color.gray)
.scaledToFit()
.frame(width: 50, height: 50)
.padding()
.cornerRadius(10)
}
}
}
.padding(.horizontal)
}
頁面底部設有一個確認按鈕,當使用者輸入名稱並選擇好圖案和分類後,點擊按鈕即可將分類資訊保存至資料庫。這個過程會呼叫 ViewModel 中的 addCategory()
方法,並在操作完成後顯示成功或失敗的通知。
Button(action: {
viewModel.addCategory(name: categoryName, iconName: selectedIconName, categoryGroup: selectedCategoryGroup)
}) {
Text("確認")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
為了提高 UX (使用者體驗),我們新增兩個 AlertToast
通知元件,一個用於顯示成功訊息,另一個用於顯示錯誤訊息。當 ViewModel 中的 showSuccessToast
為 true
或 failHandle.isFail
為 true
時,會分別觸發成功或失敗的提示。
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
dismiss()
})
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
以下提供 AddCategoryView 完整程式碼:
import SwiftUI
import AlertToast
struct AddCategoryView: View {
@State private var categoryName: String = ""
@State private var selectedCategoryGroup: CategoryGroup
@State private var selectedIconName: String
@ObservedObject var viewModel: AddCategoryViewModel
@Environment(\.dismiss) private var dismiss
init(viewModel: AddCategoryViewModel = AddCategoryViewModel()) {
_viewModel = ObservedObject(wrappedValue: viewModel)
// 確保 categoryGroups 中有資料,否則預設為一個空的 CategoryGroup
let initialGroup = viewModel.categoryGroups.first ?? CategoryGroup()
_selectedCategoryGroup = State(initialValue: initialGroup)
_selectedIconName = State(initialValue: "frying.pan")
}
let icons = [
"frying.pan", "fork.knife", "cup.and.saucer", "oven", "refrigerator.fill", "trash.fill", "scissors", "hands.and.sparkles", "microwave", "fanblades.fill", "air.purifier", "lightbulb.fill", "bed.double.fill", "sofa.fill", "cabinet.fill", "chair.fill", "book.fill", "folder.fill", "printer.fill", "desktopcomputer", "keyboard", "tshirt.fill", "shoe.fill", "eyeglasses", "bag.fill", "pencil", "eraser.fill", "beach.umbrella.fill", "comb.fill", "toilet.fill", "shower.fill", "gamecontroller.fill", "bandage.fill", "face.smiling.inverse", "bicycle", "basketball.fill", "soccerball", "tennis.racket", "dumbbell.fill", "leaf.fill", "tree.fill", "camera.macro", "carrot.fill", "birthday.cake.fill", "cup.and.saucer.fill", "wineglass.fill","mug.fill", "takeoutbag.and.cup.and.straw.fill", "waterbottle.fill", "dog.fill", "cat.fill", "hare.fill", "tortoise.fill", "lizard.fill", "bird.fill", "fish.fill", "pawprint.fill", "hammer.fill", "screwdriver.fill", "wrench.fill", "oar.2.crossed", "car.fill", "scooter", "iphone", "laptopcomputer", "ipad", "headphones", "applewatch", "tv", "speaker.fill", "keyboard.fill", "minus.plus.batteryblock.stack.fill", "camera.fill", "photo.artframe", "book.closed.fill", "tray.full.fill", "tag.fill", "flashlight.on.fill", "moon.fill", "sun.min.fill"
]
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// 輸入分類名稱
HStack {
if !selectedIconName.isEmpty {
Image(systemName: selectedIconName)
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
.foregroundColor(Color(hexString: selectedCategoryGroup.colorHex))
}
TextField("請輸入分類名稱", text: $categoryName)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
}
.padding(.horizontal)
let width = UIScreen.main.bounds.width / 6
LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 16) {
ForEach(viewModel.categoryGroups, id: \.id) { group in
let isSelected = selectedCategoryGroup == group
let backgroundColor = isSelected ? Color(hexString: group.colorHex) : Color(.clear)
let textColor = isSelected ? Color.white : Color.black
Button(action: {
selectedCategoryGroup = group
}) {
Text(group.name)
.font(.system(size: 16, weight: .bold))
.padding()
.frame(maxWidth: .infinity)
.background(backgroundColor)
.foregroundColor(textColor)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray, lineWidth: 2)
)
}
}
}
.padding(.horizontal)
.padding(.vertical)
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(icons, id: \.self) { icon in
Button(action: {
selectedIconName = icon
}) {
Image(systemName: icon)
.resizable()
.foregroundColor(selectedIconName == icon ? Color(hexString: selectedCategoryGroup.colorHex) : Color.gray)
.scaledToFit()
.frame(width: 50, height: 50)
.padding()
.cornerRadius(10)
}
}
}
.padding(.horizontal)
}
Spacer()
// 確認新增分類的按鈕
Button(action: {
viewModel.addCategory(name: categoryName, iconName: selectedIconName, categoryGroup: selectedCategoryGroup)
}) {
Text("確認")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
}
.navigationBarTitle("新增分類", displayMode: .inline)
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
dismiss()
})
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
}
}
#Preview {
AddCategoryView()
}
今天我們成功實作 AddCategoryView 和對應的 AddCategoryViewModel,讓使用者可以新增新的分類至 App 中。我們使用了動態的 UI 元件來讓使用者輸入分類名稱、選擇圖示及大分類,並將新增的分類即時保存至資料庫中。明天我們會將管理地點的功能實作出來。